Skip to content

feat: add organization-scoped user creation (POST /api/v1/users) - #23

Merged
osama1998H merged 1 commit into
masterfrom
claude/plan-issue-15-1Y02C
Mar 28, 2026
Merged

feat: add organization-scoped user creation (POST /api/v1/users)#23
osama1998H merged 1 commit into
masterfrom
claude/plan-issue-15-1Y02C

Conversation

@osama1998H

@osama1998H osama1998H commented Mar 28, 2026

Copy link
Copy Markdown
Owner

Implements Phase 1 of #15 — allows authenticated org admins to create
users inside their own organization without going through registration.

Changes:

  • Add UserService.Create method with password validation, bcrypt hashing,
    optional role assignment, audit logging, and webhook dispatch
  • Add CreateUser handler with full swagger annotations
  • Wire POST /api/v1/users route with users:write permission
  • Export ValidatePassword for reuse across services
  • Add AuditActionUserCreated constant
  • Add authorization tests (forbidden, granted, superuser bypass)
  • Add 14 service-layer unit tests covering happy paths, password policy,
    duplicate emails, cross-org isolation, and role validation

Security: org_id from JWT only, is_superuser always false, cross-org
role injection prevented, RBAC enforced via users:write permission.

https://claude.ai/code/session_01MVLBRFpkjSWd57A285vbnP

Summary by CodeRabbit

  • New Features

    • Added user creation API endpoint supporting email, password, optional full name, and role assignment
    • Implemented permission-based authorization for user creation operations
    • Added audit logging and webhook event notifications for user creation events
  • Tests

    • Extended authorization test coverage for user creation endpoint
    • Added integration tests validating user creation workflow, role assignment, and error handling

Implements Phase 1 of #15 — allows authenticated org admins to create
users inside their own organization without going through registration.

Changes:
- Add UserService.Create method with password validation, bcrypt hashing,
  optional role assignment, audit logging, and webhook dispatch
- Add CreateUser handler with full swagger annotations
- Wire POST /api/v1/users route with users:write permission
- Export ValidatePassword for reuse across services
- Add AuditActionUserCreated constant
- Add authorization tests (forbidden, granted, superuser bypass)
- Add 14 service-layer unit tests covering happy paths, password policy,
  duplicate emails, cross-org isolation, and role validation

Security: org_id from JWT only, is_superuser always false, cross-org
role injection prevented, RBAC enforced via users:write permission.

https://claude.ai/code/session_01MVLBRFpkjSWd57A285vbnP
@coderabbitai

coderabbitai Bot commented Mar 28, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR implements a new org-scoped user-creation endpoint (POST /api/v1/users) with HTTP handler, service-layer logic including password validation and bcrypt hashing, role assignment, audit logging, webhook dispatch, RBAC-protected routing, and comprehensive integration test coverage.

Changes

Cohort / File(s) Summary
Swagger & Audit
internal/api/handlers/swagger_types.go, internal/domain/audit.go
Added CreateUserRequest Swagger schema with email, password, optional full_name, and role_ids fields; introduced AuditActionUserCreated audit event constant.
HTTP Handler
internal/api/handlers/users.go
Implemented CreateUser HTTP handler: extracts actor/org from context, parses/validates request JSON, converts role_ids to UUIDs, calls service layer, responds with 201 Created; includes OpenAPI annotations.
Router & Authorization
internal/api/router.go, internal/api/router_authorization_test.go
Wired webhookSvc into UserService constructor; registered POST /api/v1/users route with RequirePermission(PermissionUsersWrite) middleware; extended authorization test coverage with privileged-route, granted-permission, and superuser-bypass cases.
Service Layer
internal/service/user.go, internal/service/auth.go, internal/service/auth_test.go
Exported ValidatePassword function; extended UserService with webhookSvc dependency; added CreateUserInput type and Create method that validates email/password, hashes via bcrypt, assigns roles, logs audit events, and dispatches webhooks; handles Postgres unique-constraint errors (pgconn.PgError code 23505).
Tests
internal/service/user_test.go
Added integration test suite TestUserService_Create validating user creation, role assignment, password hashing, cross-org email isolation, and weak-password rejection; added TestUserService_Create_AuditLog verifying audit event logging; included newTestUserService helper wiring real Postgres store.

Sequence Diagram

sequenceDiagram
    participant Client
    participant UserHandler
    participant UserService
    participant Store
    participant RoleStore
    participant AuditService
    participant WebhookService

    Client->>UserHandler: POST /api/v1/users (email, password, roles)
    UserHandler->>UserHandler: Extract actorID, orgID from context
    UserHandler->>UserHandler: Parse & validate request body
    UserHandler->>UserHandler: Convert role_ids to UUIDs
    
    UserHandler->>UserService: Create(ctx, orgID, actorID, input, ...)
    
    UserService->>UserService: Validate email & password
    UserService->>UserService: Hash password with bcrypt
    UserService->>Store: CreateUser(orgID, email, hashedPwd, fullName)
    Store-->>UserService: user (newly created)
    
    UserService->>RoleStore: GetRoleByID(orgID, roleID)
    RoleStore-->>UserService: role
    UserService->>Store: AssignRoleToUser(user.ID, role.ID)
    Store-->>UserService: ✓
    
    UserService->>AuditService: LogEvent(AuditActionUserCreated, ...)
    AuditService-->>UserService: ✓
    
    UserService->>WebhookService: Dispatch(user_id, email)
    WebhookService-->>UserService: ✓
    
    UserService-->>UserHandler: *domain.User
    UserHandler-->>Client: 201 Created + user JSON
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 A user springs forth from POST's gentle call,
Password hashed in bcrypt's tower tall,
Roles assigned, audit logs delight,
Webhooks whisper through the digital night!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the main changes and context but is missing sections from the template: it lacks explicit type classification, related issue number formatting, structured changes list, and testing section with checkboxes. Structure the description to match the template: add 'Type of change' with checkboxes, format 'Closes #15', expand 'Changes' as a bullet list, and add 'Testing' section with checkboxes indicating tests added.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main feature being added: organization-scoped user creation via POST /api/v1/users endpoint.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/plan-issue-15-1Y02C

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/service/user_test.go (1)

302-303: Consider using a more deterministic approach for async audit log verification.

The time.Sleep(500 * time.Millisecond) introduces potential flakiness in CI environments under load. However, this is a common pragmatic pattern for testing async behavior when the audit service doesn't expose a synchronization mechanism.

If flakiness becomes an issue, consider polling with a short interval and timeout:

♻️ Alternative polling approach
// Poll for audit log with timeout instead of fixed sleep
deadline := time.Now().Add(2 * time.Second)
var found bool
for time.Now().Before(deadline) {
    logs, err := store.ListAuditLogs(ctx, org.ID, domain.AuditFilter{
        Action: &action,
        Limit:  10,
    })
    if err == nil {
        for _, log := range logs {
            if log.ResourceID != nil && *log.ResourceID == user.ID.String() {
                found = true
                break
            }
        }
    }
    if found {
        break
    }
    time.Sleep(50 * time.Millisecond)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/user_test.go` around lines 302 - 303, Replace the fixed
time.Sleep(500 * time.Millisecond) in the test with a deterministic polling
approach: repeatedly call store.ListAuditLogs(ctx, org.ID,
domain.AuditFilter{Action: &action, Limit: 10}) on a short interval (e.g., 50ms)
until you find an audit entry whose ResourceID matches user.ID.String() or a
deadline (e.g., 2s) elapses; fail the test if the deadline is reached without
finding the expected log. This change removes the flaky fixed sleep and makes
verification robust for the async audit goroutine.
internal/service/user.go (1)

43-59: Standardize error wrapping with service/function context.

On Lines 43-59, some returns are unwrapped (return nil, err) or use non-standard prefixes ("hash password: %w", "create user: %w"). This weakens traceability and makes error handling less uniform across services.

💡 Suggested wrapping pattern
 	if err := ValidatePassword(in.Password); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("service.UserService.Create: %w", err)
 	}
 ...
 	hashed, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
 	if err != nil {
-		return nil, fmt.Errorf("hash password: %w", err)
+		return nil, fmt.Errorf("service.UserService.Create: hash password: %w", err)
 	}
 ...
-		return nil, fmt.Errorf("create user: %w", err)
+		return nil, fmt.Errorf("service.UserService.Create: create user: %w", err)
 	}

As per coding guidelines "Always use sentinel errors from internal/domain/errors.go. Wrap errors with context using fmt.Errorf() in the format: fmt.Errorf(\"<package>.<function>: %w\", <error>)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/user.go` around lines 43 - 59, Wrap all errors returned from
this block with the standardized package.function sentinel pattern instead of
returning raw errs or ad-hoc prefixes; e.g., replace bare returns like `return
nil, err` and messages like `fmt.Errorf("hash password: %w", err)` /
`fmt.Errorf("create user: %w", err)` with `fmt.Errorf("service.CreateUser: %w",
err)` (or use the exact service method name if different) and when mapping
domain unique constraint use `fmt.Errorf("service.CreateUser: %w",
domain.ErrAlreadyExists)` combined with the pg error check; update the
ValidatePassword, bcrypt.GenerateFromPassword, and s.store.CreateUser error
returns to follow this `fmt.Errorf("<package>.<function>: %w", err)` convention
referencing ValidatePassword, bcrypt.GenerateFromPassword, and
s.store.CreateUser locations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/service/user.go`:
- Around line 52-67: Currently CreateUser is called before role
validation/assignment which can leave a user persisted when role checks or
AssignRoleToUser fail; change the flow to validate all role IDs first by calling
GetRoleByID for each role in in.RoleIDs, then create the user with
s.store.CreateUser, and finally assign roles with s.store.AssignRoleToUser.
Prefer doing the create+assign sequence inside a single transactional operation
(use the store's transaction API or a single transactional method) so that
failures during AssignRoleToUser roll back the created user; update error
messages to still wrap errors (e.g., "create user" / "assign role %s") as
before.

---

Nitpick comments:
In `@internal/service/user_test.go`:
- Around line 302-303: Replace the fixed time.Sleep(500 * time.Millisecond) in
the test with a deterministic polling approach: repeatedly call
store.ListAuditLogs(ctx, org.ID, domain.AuditFilter{Action: &action, Limit: 10})
on a short interval (e.g., 50ms) until you find an audit entry whose ResourceID
matches user.ID.String() or a deadline (e.g., 2s) elapses; fail the test if the
deadline is reached without finding the expected log. This change removes the
flaky fixed sleep and makes verification robust for the async audit goroutine.

In `@internal/service/user.go`:
- Around line 43-59: Wrap all errors returned from this block with the
standardized package.function sentinel pattern instead of returning raw errs or
ad-hoc prefixes; e.g., replace bare returns like `return nil, err` and messages
like `fmt.Errorf("hash password: %w", err)` / `fmt.Errorf("create user: %w",
err)` with `fmt.Errorf("service.CreateUser: %w", err)` (or use the exact service
method name if different) and when mapping domain unique constraint use
`fmt.Errorf("service.CreateUser: %w", domain.ErrAlreadyExists)` combined with
the pg error check; update the ValidatePassword, bcrypt.GenerateFromPassword,
and s.store.CreateUser error returns to follow this
`fmt.Errorf("<package>.<function>: %w", err)` convention referencing
ValidatePassword, bcrypt.GenerateFromPassword, and s.store.CreateUser locations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f30bbdab-180b-4d11-8a4b-c225effb0977

📥 Commits

Reviewing files that changed from the base of the PR and between 39fffb7 and 51680ff.

📒 Files selected for processing (9)
  • internal/api/handlers/swagger_types.go
  • internal/api/handlers/users.go
  • internal/api/router.go
  • internal/api/router_authorization_test.go
  • internal/domain/audit.go
  • internal/service/auth.go
  • internal/service/auth_test.go
  • internal/service/user.go
  • internal/service/user_test.go

Comment thread internal/service/user.go
Comment on lines +52 to +67
user, err := s.store.CreateUser(ctx, orgID, in.Email, string(hashed), in.FullName, false)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, fmt.Errorf("%w: email already exists in this organization", domain.ErrAlreadyExists)
}
return nil, fmt.Errorf("create user: %w", err)
}

for _, roleID := range in.RoleIDs {
if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil {
return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID)
}
if err := s.store.AssignRoleToUser(ctx, orgID, user.ID, roleID); err != nil {
return nil, fmt.Errorf("assign role %s: %w", roleID, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Create can persist a user even when role validation fails.

On Line 52, the user is inserted before role validation/assignment. If any role is invalid (Line 62) or assignment fails (Line 65), the method returns an error but leaves the user created, which is a partial-success bug.

💡 Suggested fix (validate roles first, then create/assign)
+	// Validate all requested roles before creating the user to avoid partial success.
+	for _, roleID := range in.RoleIDs {
+		if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil {
+			if errors.Is(err, domain.ErrNotFound) {
+				return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID)
+			}
+			return nil, fmt.Errorf("service.UserService.Create: get role %s: %w", roleID, err)
+		}
+	}
+
 	user, err := s.store.CreateUser(ctx, orgID, in.Email, string(hashed), in.FullName, false)
 	if err != nil {
 		var pgErr *pgconn.PgError
 		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
 			return nil, fmt.Errorf("%w: email already exists in this organization", domain.ErrAlreadyExists)
 		}
 		return nil, fmt.Errorf("create user: %w", err)
 	}
 
 	for _, roleID := range in.RoleIDs {
-		if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil {
-			return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID)
-		}
 		if err := s.store.AssignRoleToUser(ctx, orgID, user.ID, roleID); err != nil {
-			return nil, fmt.Errorf("assign role %s: %w", roleID, err)
+			return nil, fmt.Errorf("service.UserService.Create: assign role %s: %w", roleID, err)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/user.go` around lines 52 - 67, Currently CreateUser is
called before role validation/assignment which can leave a user persisted when
role checks or AssignRoleToUser fail; change the flow to validate all role IDs
first by calling GetRoleByID for each role in in.RoleIDs, then create the user
with s.store.CreateUser, and finally assign roles with s.store.AssignRoleToUser.
Prefer doing the create+assign sequence inside a single transactional operation
(use the store's transaction API or a single transactional method) so that
failures during AssignRoleToUser roll back the created user; update error
messages to still wrap errors (e.g., "create user" / "assign role %s") as
before.

@osama1998H
osama1998H merged commit c3cc9df into master Mar 28, 2026
4 checks passed
@osama1998H
osama1998H deleted the claude/plan-issue-15-1Y02C branch March 28, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants